iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0

今天要做什麼?

昨天我們學會了參數化測試,用優雅的方式處理大量測試資料。今天要解決一個新挑戰:「如何測試依賴外部服務的程式碼?」

想像你有個寄送通知的功能,它會真的寄出 email。測試時,你不希望真的寄信出去。這時候就需要「測試替身」來幫忙了!

學習目標

今天結束後,你將學會:

  • 理解測試替身的概念與種類
  • 掌握 Pest 的 Mockery 用法
  • 學會 Stub、Mock、Spy 的使用場景
  • 掌握測試替身的最佳實踐

TDD 學習地圖

第一階段:打好基礎(Day 1-10)
├── Day 01 - 環境設置與第一個測試
├── Day 02 - 認識斷言(Assertions)
├── Day 03 - TDD 紅綠重構循環
├── Day 04 - 測試結構與組織
├── Day 05 - 測試生命週期
├── Day 06 - 參數化測試
├── Day 07 - 測試替身基礎 ★ 今天在這裡
├── ...
└── (更多精彩內容待續)

什麼是測試替身? 🎪

測試替身(Test Double)就像電影中的替身演員,在測試時代替真實的依賴物件。

三種主要類型

  1. Stub(存根) - 提供固定回應

    • 像是自動販賣機:投錢就給飲料
    • 不在乎被呼叫幾次
  2. Mock(模擬) - 驗證互動行為

    • 像是考官:檢查你有沒有做對步驟
    • 會驗證方法是否被正確呼叫
  3. Spy(間諜) - 監控真實行為

    • 像是監視器:記錄發生了什麼事
    • 保留原始功能,同時記錄呼叫

實戰演練 🚀

範例 1:使用 Mock 測試通知服務

先建立一個簡單的 EmailService 和 NotificationService。

建立 app/Services/EmailService.php

<?php

namespace App\Services;

class EmailService
{
    public function send(string $to, string $subject, string $body): bool
    {
        // 實際實作會真的寄信
        echo "Sending email to {$to}\n";
        return true;
    }
}

建立 app/Services/NotificationService.php

<?php

namespace App\Services;

class NotificationService
{
    public function __construct(
        private EmailService $emailService
    ) {}
    
    public function notify(string $userEmail, string $message): bool
    {
        return $this->emailService->send(
            $userEmail,
            'Notification',
            $message
        );
    }
}

建立 tests/Unit/Day07/NotificationServiceTest.php

<?php

use App\Services\EmailService;
use App\Services\NotificationService;

describe('NotificationService with Mock', function() {
    it('sends email when notifying user', function() {
        // 建立 Mock
        $mockEmailService = Mockery::mock(EmailService::class);
        $mockEmailService->shouldReceive('send')
            ->once()
            ->with('user@example.com', 'Notification', 'Hello!')
            ->andReturn(true);
        
        $notificationService = new NotificationService($mockEmailService);
        
        // 執行測試
        $result = $notificationService->notify('user@example.com', 'Hello!');
        
        // 驗證結果
        expect($result)->toBe(true);
    });
});

範例 2:使用 Stub 測試遊戲服務

建立 app/Services/RandomGenerator.php

<?php

namespace App\Services;

class RandomGenerator
{
    public function generate(int $min, int $max): int
    {
        return rand($min, $max);
    }
}

建立 app/Services/GameService.php

<?php

namespace App\Services;

class GameService
{
    public function __construct(
        private RandomGenerator $randomGenerator
    ) {}
    
    public function rollDice(): int
    {
        return $this->randomGenerator->generate(1, 6);
    }
    
    public function isWinning(int $diceValue): bool
    {
        return $diceValue >= 4;
    }
}

建立 tests/Unit/Day07/GameServiceTest.php

<?php

use App\Services\RandomGenerator;
use App\Services\GameService;

describe('GameService with Stub', function() {
    it('wins when dice value is 4 or higher', function() {
        // 建立 Stub - 固定回傳 5
        $stubRandomGenerator = Mockery::mock(RandomGenerator::class);
        $stubRandomGenerator->shouldReceive('generate')
            ->with(1, 6)
            ->andReturn(5);
        
        $gameService = new GameService($stubRandomGenerator);
        
        $diceValue = $gameService->rollDice();
        $isWin = $gameService->isWinning($diceValue);
        
        expect($diceValue)->toBe(5);
        expect($isWin)->toBe(true);
    });
    
    it('loses when dice value is less than 4', function() {
        // 建立 Stub - 固定回傳 2
        $stubRandomGenerator = Mockery::mock(RandomGenerator::class);
        $stubRandomGenerator->shouldReceive('generate')
            ->with(1, 6)
            ->andReturn(2);
        
        $gameService = new GameService($stubRandomGenerator);
        
        $diceValue = $gameService->rollDice();
        $isWin = $gameService->isWinning($diceValue);
        
        expect($diceValue)->toBe(2);
        expect($isWin)->toBe(false);
    });
});

範例 3:使用 Spy 監控方法呼叫

建立 app/Services/Logger.php

<?php

namespace App\Services;

class Logger
{
    public function log(string $message): void
    {
        echo "[LOG] {$message}\n";
    }
}

建立 app/Services/Calculator.php

<?php

namespace App\Services;

class Calculator
{
    public function __construct(
        private Logger $logger
    ) {}
    
    public function add(int $a, int $b): int
    {
        $result = $a + $b;
        $this->logger->log("Adding {$a} + {$b} = {$result}");
        return $result;
    }
    
    public function subtract(int $a, int $b): int
    {
        $result = $a - $b;
        $this->logger->log("Subtracting {$a} - {$b} = {$result}");
        return $result;
    }
}

建立 tests/Unit/Day07/CalculatorWithSpyTest.php

<?php

use App\Services\Calculator;
use App\Services\Logger;

describe('Calculator with Spy', function() {
    it('logs calculation when adding', function() {
        // 使用 Spy 監控 log 方法
        $logger = Mockery::spy(Logger::class);
        
        $calculator = new Calculator($logger);
        $result = $calculator->add(2, 3);
        
        // 驗證計算結果
        expect($result)->toBe(5);
        
        // 驗證 log 被呼叫
        $logger->shouldHaveReceived('log')
            ->once()
            ->with('Adding 2 + 3 = 5');
    });
    
    it('logs calculation when subtracting', function() {
        $logger = Mockery::spy(Logger::class);
        
        $calculator = new Calculator($logger);
        $result = $calculator->subtract(5, 3);
        
        expect($result)->toBe(2);
        
        $logger->shouldHaveReceived('log')
            ->once()
            ->with('Subtracting 5 - 3 = 2');
    });
});

使用時機 🎯

何時用 Stub?

  • 需要固定的測試資料
  • 外部服務的回應不重要
  • 想要控制測試環境

何時用 Mock?

  • 需要驗證方法被呼叫
  • 關心互動的正確性
  • 測試物件之間的協作

何時用 Spy?

  • 想要保留原始行為
  • 需要監控方法呼叫
  • 部分模擬真實物件

最佳實踐 💡

1. 保持測試簡單

// ✅ 好的做法:清楚的測試意圖
it('sends notification email', function() {
    $mockEmail = Mockery::mock(EmailService::class);
    $mockEmail->shouldReceive('send')->andReturn(true);
    // ... 簡單明瞭的測試
});

// ❌ 避免:過度複雜的設置
it('does everything', function() {
    // 10 行的 mock 設置...
});

2. 一次測一件事

// ✅ 好的做法:專注單一行為
it('calls email service with correct parameters', function() {
    // 只測試參數傳遞
});

it('returns true when email is sent successfully', function() {
    // 只測試回傳值
});

3. 適當的驗證

// ✅ 好的做法:驗證重要的互動
$mock->shouldReceive('send')
    ->once()
    ->with($expectedParams);

// ❌ 避免:過度驗證
$mock->shouldReceive('method1')->times(1);
$mock->shouldReceive('method2')->times(2);
$mock->shouldReceive('method3')->times(3);
// ... 太多不必要的驗證

今日回顧 📝

今天我們學會了:

測試替身的三種類型

  • Stub:提供固定回應
  • Mock:驗證互動行為
  • Spy:監控真實物件

Mockery 測試工具

  • Mockery::mock():建立 Mock 物件
  • Mockery::spy():建立 Spy 物件
  • shouldReceive():設定預期行為
  • shouldHaveReceived():驗證已發生行為

實務應用

  • EmailService 的 Mock 測試
  • GameService 的 Stub 測試
  • Calculator 的 Spy 測試

小練習 🏆

試著為以下 PaymentService 寫測試:

class PaymentService
{
    public function __construct(
        private PaymentGateway $gateway,
        private Logger $logger
    ) {}
    
    public function processPayment(float $amount): bool
    {
        $this->logger->log("Processing payment: \${$amount}");
        
        if ($amount <= 0) {
            $this->logger->log('Invalid amount');
            return false;
        }
        
        $result = $this->gateway->charge($amount);
        $this->logger->log("Payment result: " . ($result ? 'success' : 'failed'));
        
        return $result;
    }
}

提示:

  1. Mock PaymentGatewaycharge 方法
  2. Spy Loggerlog 方法
  3. 測試正常付款和無效金額的情況

明天我們將學習「例外處理測試」,了解如何測試錯誤情況! 🚀


上一篇
Day 06 - 參數化測試 🔢
下一篇
Day 08 - 例外處理測試 ⚠️
系列文
Laravel Pest TDD 實戰:從零開始的測試驅動開發11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言